基于 Codemod 的前端代码迁移工具开发
前端基础设施升级很少只是修改 package.json。以 React 18 和 Ant Design 4 升级为 React 18 和 Ant Design 5 为例,真正消耗时间的通常是散落在大量项目中的兼容性修改:组件属性更名、路由 API 变化、旧模块导入、样式覆盖失效,以及无法安全自动判断的边界场景。
这类工作如果完全依赖人工,会遇到三个问题:容易遗漏、修改口径不一致、迁移结果难以统计。Codemod 的价值,就是把已经确认的迁移规则固化成可重复执行的程序。
本文总结一个实际迁移工具的设计与实现。示例中的包名、组件名和配置均已泛化,不包含具体业务代码。
先澄清两个名称
- Codemod 不是某个特定工具,而是一类“用程序批量修改代码”的工程方法。
- jscodeshift 是 JavaScript/TypeScript 生态中常用的 Codemod 工具,它基于 Recast 提供 AST 查询和修改 API,并尽量保留原代码格式。
有时会把它们误写成 codemode 或 jsshift,准确名称分别是 codemod 和 jscodeshift。
为什么不能只用字符串替换
假设要把组件的 overlay 属性改成 title:
<Tooltip overlay={content}>帮助</Tooltip>最简单的字符串替换看起来可行,但它无法可靠区分:
- JSX 属性和普通对象字段;
- 同名业务变量和组件 API;
- 目标组件与用户自己封装的同名组件;
- 注释、字符串和真正的代码;
- 属性展开带来的间接传参。
AST 会先把源码解析成结构化语法树。规则可以只匹配 JSXElement 中名称为 Tooltip 的节点,再修改它的 JSXAttribute。这使“改什么”和“不改什么”都更明确。
不过 AST 也不是万能的。下面这种写法无法只根据局部语法判断 props 中是否包含旧属性:
<Tooltip {...props}>帮助</Tooltip>因此迁移工具不能只有“自动替换”,还需要“发现问题并交给人工确认”的能力。
工具的目标与边界
这个工具最终形成了三类规则:
| 规则类型 | 作用 | 典型场景 |
|---|---|---|
auto-fix | 对确定性变更直接修改源码 | 属性更名、导入路径替换、调用参数换序 |
report | 只定位风险,不修改源码 | 组件 DOM 结构变化、运行时语义变化、属性展开 |
script | 修改项目级文件,不逐个遍历源码 | 依赖版本、构建脚本、配置文件迁移 |
这里最重要的原则是:只有能够证明语义等价的修改才自动执行,其余情况只报告。
Codemod 负责减少机械劳动,不负责替开发者做业务判断,也不能替代类型检查、单元测试和人工回归。
整体架构
工具没有直接使用 jscodeshift 自带的 CLI,而是在它上面实现了一层迁移编排器。原因是单个 transform 只能解决一条规则,而真实升级需要:
- 顺序执行多条规则;
- 区分自动修复和人工审查;
- 同时修改源码与项目配置;
- 支持规则启停、排除目录和多源码目录;
- 汇总 JSON、Markdown 和终端统计;
- 记录某条规则修改了哪个文件、哪一行;
- 在预览模式下运行完整流程但不写入源码。
简化后的目录如下:
migrate-tool/
├── bin/
│ └── cli.js
├── scripts/
│ ├── migrate.js
│ ├── prepare-cases.js
│ └── stats.js
├── transforms/
│ ├── auto-fix/
│ ├── report/
│ ├── script/
│ └── utils/
├── fixtures/
└── migrate.config.js执行链路可以概括为:
CLI 参数 + 项目配置
↓
动态发现并过滤规则
↓
先执行项目级 script 规则
↓
扫描目标源码文件
↓
按文件串行执行 auto-fix 规则
↓
基于变更后的源码执行 report 规则
↓
写回源码(非 dry-run)
↓
生成 JSON / Markdown 报告为什么自动修复要按文件串行执行
同一个文件可能连续命中多条规则。前一条规则的输出必须作为后一条规则的输入:
let currentSource = source;
for (const rule of autoFixRules) {
const result = rule.transform(
{ path: filePath, source: currentSource },
{ jscodeshift: j },
options,
);
if (result.modified) {
currentSource = result.source;
}
}如果每条规则都读取最初的源码,后执行规则会覆盖前面规则的结果。串行传递 currentSource 可以避免这个问题。
这也意味着规则顺序是迁移契约的一部分。如果规则 B 依赖规则 A 生成的新结构,就不能依赖文件系统的默认排序,应该显式声明顺序或依赖关系。
规则契约
自动修复规则
一条自动修复规则包含名称、类型、描述和转换函数:
module.exports = {
name: "tooltip-overlay-to-title",
type: "auto-fix",
description: "将 Tooltip 的 overlay 属性迁移为 title",
transform(fileInfo, api) {
const j = api.jscodeshift;
const root = j(fileInfo.source);
const changes = [];
root.find(j.JSXElement).forEach((path) => {
const opening = path.value.openingElement;
if (opening.name.type !== "JSXIdentifier") return;
if (opening.name.name !== "Tooltip") return;
const attribute = opening.attributes.find(
(item) =>
item.type === "JSXAttribute" &&
item.name.name === "overlay",
);
if (!attribute) return;
attribute.name.name = "title";
changes.push({
line: attribute.loc?.start.line ?? 0,
message: "Tooltip: overlay → title",
});
});
return {
modified: changes.length > 0,
source: changes.length > 0 ? root.toSource() : null,
changes,
};
},
};返回值中的 changes 不只是为了打印日志。它还是迁移的审计记录,可以回答:
- 哪条规则执行了修改;
- 修改发生在哪个文件和哪一行;
- 一共修改了多少处;
- 失败后应该优先检查哪些文件。
报告规则
报告规则只返回 findings:
module.exports = {
name: "dropdown-overlay-review",
type: "report",
description: "检查无法安全自动迁移的 Dropdown overlay",
transform(fileInfo, api) {
const j = api.jscodeshift;
const root = j(fileInfo.source);
const findings = [];
root.find(j.JSXElement).forEach((path) => {
// 省略具体匹配逻辑
findings.push({
line: path.value.loc?.start.line ?? 0,
severity: "warning",
message: "需要确认菜单数据能否转换为 items",
suggestion: "检查事件回调、动态菜单和自定义渲染",
});
});
return { findings };
},
};报告规则适合这些情况:
- 新旧 API 不是一一映射;
- 修改依赖运行时数据;
- 需要理解跨文件类型或调用关系;
- 组件 DOM 结构变化可能让定制样式失效;
- React 18 自动批处理、并发渲染等语义变化;
- 自动修复规则只处理了安全子集,剩余情况必须暴露出来。
自动修复规则也可以同时返回 findings。例如规则能处理显式属性,却发现了 {...props},这时应完成确定部分,并把不确定部分加入报告。
项目级脚本规则
并非所有迁移对象都是 JS/TS AST。package.json、.npmrc、构建脚本等更适合单独处理:
module.exports = {
name: "upgrade-project-config",
type: "script",
description: "升级依赖与项目配置",
execute({ srcDir, dryRun, config }) {
return {
changes: [],
findings: [],
warnings: [],
};
},
};这里应针对文件类型选择正确工具:JSON 应解析后修改对象,Shell 或 properties 文件至少要做精确锚点匹配,不能把所有文件都当成 JavaScript AST。
规则发现与配置
工具通过目录动态加载规则。新增规则只需放入对应目录,不需要修改中央注册表:
function loadRules(directory) {
return fs
.readdirSync(directory)
.filter((file) => file.endsWith(".js"))
.map((file) => require(path.join(directory, file)));
}项目配置只保留编排层选项:
module.exports = {
srcDir: ["src", "packages"],
output: "reports/migration-report.json",
exclude: [
"**/*.test.*",
"**/fixtures/**",
"**/vendor/**",
],
disabled: ["unsafe-experimental-rule"],
// 调试时 only 的优先级高于 disabled
// only: ["tooltip-overlay-to-title"],
};CLI 提供 --src、--mode、--output、--config、--dry-run 和 --show-rules 等参数。命令行参数应覆盖配置文件,以便在 CI 或临时调试时缩小范围。
JS/TS 的解析与转换
工具统一使用 TSX parser 处理 .js、.jsx、.ts 和 .tsx:
const j = jscodeshift.withParser("tsx");
const root = j(source);这样可以覆盖 JSX、TypeScript 和装饰器等常见语法。但“一个 parser 处理所有 JS 方言”仍然有边界:Flow、遗留 Babel 插件语法、非标准装饰器配置都可能解析失败。一个更稳妥的设计是允许规则或文件类型声明 parser:
module.exports = {
parser: "tsx",
// ...
};jscodeshift 官方也支持 transform 导出 babel、flow、ts、tsx 等 parser,或传入兼容 Recast 的自定义 parser。
自动修复规则如何分级
从实际规则中可以归纳出四个复杂度层次。
1. 局部语法替换
例如 JSX 属性更名、导入源替换。这类规则的输入和输出结构稳定,最适合自动修复。
// before
<Tooltip overlay={content} />
// after
<Tooltip title={content} />2. 结构调整
例如把 require() 提升为顶层 import,或把一个路由配置拆成两项。这类规则需要处理作用域、变量冲突、导出语句和插入位置。
// before
const icon = require("./icon.svg");
// after
import icon from "./icon.svg";结构调整不能只保证“能打印代码”,还要验证:
- 新 import 是否重名;
- 原变量是否在多个作用域中使用;
- CommonJS 与 ESM 互操作是否一致;
- 注释是否跟随正确节点;
- 再运行一次是否保持不变。
3. 跨语句数据流改写
例如把路由注入的 props.match.params 改成 Hook。规则需要识别函数组件、参数解构、成员表达式、已有 Hook 导入、默认导出和高阶组件包装。
这类规则代码量会迅速增长。实际项目中,最复杂的单条规则超过两千行,这已经是一个明显信号:规则内部应该拆成“检测、规划、修改、验证”几个阶段,并为不同输入形态建立 fixture,而不是继续堆条件分支。
4. 运行时语义变化
React 18 自动批处理、render 阶段副作用、组件内部 DOM 变化,都不是简单的语法替换。它们更适合报告规则:先缩小人工排查范围,再由开发者判断真实语义。
报告设计
迁移工具同时生成机器可读的 JSON 和适合人工阅读的 Markdown。
{
"summary": {
"totalFiles": 120,
"modifiedFiles": 18,
"totalChanges": 46,
"totalFindings": 23
},
"autoFix": {},
"report": {},
"errors": {
"timeoutFiles": [],
"errorFiles": [],
"totalErrors": 0
}
}JSON 适合做二次统计或接入流水线,Markdown 应按规则和文件分组,至少包含:
- 规则说明;
- 文件路径与行号;
- 原代码片段;
- 风险等级;
- 修改建议;
- 解析失败和超时文件。
只打印“修改了 46 处”是不够的。迁移的真正交付物不是一批 diff,而是“修改结果 + 未自动处理的问题 + 失败清单”。
安全机制
Dry-run
--dry-run 应运行完整的扫描和规则链,只禁止写回源码。这样预览报告与真实执行使用的是同一套逻辑,不会出现“预览是一套代码,执行是另一套代码”。
需要注意:报告本身通常仍会写入磁盘,因此 dry-run 的准确含义是“不修改目标源码”,而不是“完全不产生文件”。
排除规则
默认排除 node_modules、构建产物和版本控制目录,并允许使用 glob 排除测试、fixture、vendor 或暂不迁移的目录。
排除逻辑必须基于相对路径统一处理,否则绝对路径、Windows 分隔符和多源码目录组合时容易失效。
错误隔离
单个文件解析失败不应中断整个仓库迁移。错误应记录规则名、文件名和错误类型,然后继续处理其他文件。
但错误隔离也不能把失败静默吞掉。只要存在解析失败,最终报告就必须显式展示,否则“扫描完成”会造成错误的安全感。
幂等性
一条合格的自动修复规则应该满足:
transform(transform(source)) === transform(source)幂等性可以防止重复添加 import、重复包装组件或重复创建配置。它也是最值得加入自动化验证的属性之一。
测试迁移规则
迁移规则适合使用输入/输出 fixture,而不是只测试几个 AST 工具函数:
fixtures/
└── tooltip-overlay-to-title/
├── basic.input.tsx
├── basic.output.tsx
├── spread-props.input.tsx
└── spread-props.report.json每条规则至少覆盖:
- 一个标准命中场景;
- 一个不应修改的反例;
- 一个语法边界场景;
- 一个需要报告而不能自动修改的场景;
- 连续执行两次的幂等性;
- 注释和格式保留情况。
实际工具通过把只读样例复制到工作目录,保证每次本地演练都从干净输入开始。这个思路适合人工开发阶段,但长期仍应补充自动断言,否则只能证明“脚本跑完了”,不能证明输出正确。
当前规则覆盖了什么
在一次真实升级中,工具沉淀了 12 条自动修复规则、16 条报告规则和 2 条项目级脚本规则。为了避免绑定具体内部工程,这里只按能力分类:
| 类别 | 处理内容 |
|---|---|
| 组件 API | 属性更名、废弃属性、组件结构调整 |
| React 18 | 自动批处理风险、render 阶段副作用 |
| 路由升级 | 参数顺序、可选参数、注入 props 改 Hook |
| 模块系统 | 导入路径迁移、require 转 import |
| 样式兼容 | 旧样式入口、组件定制样式风险 |
| 工程配置 | 依赖版本、包管理器、构建配置 |
规则数量不是主要指标。更重要的是规则是否说明了安全边界,以及未覆盖场景是否进入报告。
Less/CSS 能否使用类似的工具
可以,但不能直接用 jscodeshift 解析 Less 或 CSS。
jscodeshift 面向 JavaScript/TypeScript AST。样式代码需要自己的 parser、AST visitor 和 stringifier。对 CSS、Less、SCSS 的源码级迁移,最合适的同类基础设施是 PostCSS:
| 需求 | 建议工具 | 说明 |
|---|---|---|
| CSS AST 修改 | postcss | 遍历 rule、at-rule、declaration 等节点 |
| Less 源码修改 | postcss + postcss-less | 解析并重新输出 Less,不负责把 Less 编译为 CSS |
| SCSS 源码修改 | postcss + postcss-scss | 保留 SCSS 语法进行源码变换 |
| 只做规范检查 | Stylelint 自定义规则 | 适合报告模式,也支持 fixable 规则 |
| 需要 Less 求值语义 | Less.js plugin | 适合编译前后处理,不一定适合保留原源码格式 |
PostCSS 官方将自己定位为 CSS 语法转换工具,并通过自定义 parser/stringifier 支持 Less、SCSS 等语法。postcss-less 的目标是让 PostCSS 操作 Less 源码,而不是编译 Less,这一点非常适合 Codemod。
当前实现中的样式处理缺口
实际项目已经写了几条样式报告规则,用正则检测 Less/CSS 中的旧 @import,以及检测可能受组件 DOM 变化影响的定制选择器。规则也声明了:
fileTypes: ["less", "css", "scss", "sass"]但主扫描器只收集:
/\.(jsx?|tsx?)$/因此这些样式文件不会进入规则执行链。这里不是“样式迁移已经支持”,而是“规则接口预留了样式类型,但扫描与解析链路尚未打通”。
此外,个别样式报告规则在检查扩展名之前就调用了 j(fileInfo.source)。即使把 .less 加入扫描范围,也会先尝试用 JavaScript parser 解析 Less 并失败。
修复不能只把扫描正则改成 /\.(jsx?|tsx?|less|css)$/,还需要把 parser 选择下沉到语言适配层。
一个统一的多语言迁移架构
比较合理的演进方式,是保留现有编排器,把“如何解析和打印”从规则执行中抽出来:
┌─ JS/TS adapter ─ jscodeshift + Recast
文件扫描 → 语言路由 ├─ CSS adapter ─ PostCSS
├─ Less adapter ─ PostCSS + postcss-less
└─ Config adapter─ JSON / 文本专用处理器
↓
auto-fix / report 统一结果协议规则声明自己支持的语言,而不是在函数内部猜测:
module.exports = {
name: "replace-legacy-style-import",
type: "auto-fix",
languages: ["css", "less"],
transform({ root }) {
const changes = [];
root.walkAtRules("import", (atRule) => {
if (!atRule.params.includes("legacy-ui")) return;
atRule.params = atRule.params.replace("legacy-ui", "modern-ui");
changes.push({
line: atRule.source?.start?.line ?? 0,
message: "更新样式导入路径",
});
});
return { modified: changes.length > 0, changes };
},
};Less adapter 可以这样处理源码:
const postcss = require("postcss");
const lessSyntax = require("postcss-less");
async function transformLess(source, from, plugins) {
const result = await postcss(plugins).process(source, {
from,
syntax: lessSyntax,
map: false,
});
return result.css;
}在统一结果协议下,JS 和样式规则都返回:
interface ITransformResult {
modified: boolean;
source?: string;
changes?: IChange[];
findings?: IFinding[];
}编排器只关心结果,不关心底层是 jscodeshift 还是 PostCSS。
正则在样式迁移中的位置
正则不是完全不能用。检测单行、格式稳定的 @import,用正则实现成本很低。但一旦涉及这些情况,就应该转 AST:
- 多行
@import或url(); - 嵌套选择器和
&; - Less 变量、mixin、guard;
- 声明值中的函数和插值;
- 需要修改选择器而不是只做提示;
- 需要尽量保留注释和源码位置。
一个实用策略是:正则做低成本扫描和报告,AST 做可靠自动修复。
Stylelint 是否可以替代 PostCSS Codemod
Stylelint 的自定义规则本质上会接收 PostCSS Root,因此很适合实现“发现问题”的报告规则。它也支持可修复规则,但如果迁移需要:
- 多步骤编排;
- 同时修改 JS、样式和项目配置;
- 输出统一迁移报告;
- 管理规则先后依赖;
那么 Stylelint 更适合作为样式规则引擎之一,而不是整个迁移工具的编排层。
从实现中暴露出的工程问题
1. 文件扫描与规则声明脱节
规则声明支持样式文件,扫描器却只找 JS/TS。扫描范围应该由已启用规则的 languages 或 fileTypes 合并得出,而不是硬编码在主流程中。
2. mode 与脚本规则边界不够清晰
all、auto-fix、report 控制了逐文件规则,但项目级脚本在文件扫描前统一执行。如果用户只想生成报告,脚本规则仍可能修改配置文件。
更安全的设计是增加独立的 script mode,或要求项目级规则也明确声明 mutatesFiles,并统一受 dry-run 和 mode 控制。
3. 超时不是强制中断
主流程记录了单文件时间预算,但转换函数是同步执行的。只能在规则开始前检查已经消耗的时间,无法中断一条正在阻塞的规则。
如果确实需要强制超时,应把文件处理放进 Worker Thread 或子进程,在超时后终止 worker。否则更准确的名称应是“耗时预算告警”。
4. 主执行文件职责过多
主脚本同时负责 CLI 参数、配置合并、规则加载、文件扫描、parser 路由、规则执行、错误收集和 Markdown 生成。随着语言和规则增加,它会成为新的维护瓶颈。
可以按职责拆为:
cli → config → scanner → language adapters → rule runner → reporter这是实现层拆分,不需要引入复杂框架。每层保持一个清晰输入输出即可。
5. 缺少显式规则顺序
动态读取目录很方便,但文件名排序不应成为隐含依赖。建议规则增加:
{
name: "rule-b",
after: ["rule-a"],
}加载阶段做拓扑排序,并在循环依赖时直接失败。
6. 报告规则看到的是自动修复后的源码
当前执行顺序是先 auto-fix,后 report,因此报告针对的是“迁移后的剩余问题”。这通常是合理的,但行号已经可能不同于原始源码。
报告中应明确记录阶段,或者保留原始位置映射。否则开发者拿 dry-run 报告对照未修改文件时,行号可能出现偏差。
推荐的落地顺序
如果从零开发一个类似工具,可以按以下顺序推进:
- 先收集迁移清单,把规则分为“可自动修复”和“只报告”。
- 建立统一规则协议和 fixture,不急着做复杂 CLI。
- 先实现 2~3 条局部 AST 规则,验证解析、打印和幂等性。
- 增加 dry-run、排除目录、错误隔离和 JSON 报告。
- 再加入 Markdown 报告、规则筛选和项目级脚本。
- 需要处理样式时,引入语言 adapter,不要让 jscodeshift 强行解析所有文件。
- 最后再处理跨语句、跨文件的复杂规则;收益低或误判高的场景保持报告模式。
总结
一个可用的 Codemod 工具不只是若干 AST 替换脚本。它至少包含四层能力:
- 语言层:为 JS/TS、CSS/Less 和配置文件选择正确 parser;
- 规则层:明确自动修复、人工报告和项目脚本的边界;
- 编排层:控制规则顺序、文件范围、dry-run 和错误隔离;
- 交付层:输出可审计的修改记录、剩余问题和失败清单。
jscodeshift 适合处理 JavaScript/TypeScript,PostCSS 加 postcss-less 适合处理 Less/CSS。二者不需要互相替代,更适合被同一个迁移编排器统一调度。
Codemod 最重要的价值也不是“自动改得越多越好”,而是把迁移知识变成明确、可重复、可审查的规则,并把无法安全判断的部分如实交还给人。
参考资料
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
